JNI 编程上手指南之 JNI 数据类型

7/3/2023

视频课程与答疑服务请咨询微信号 zzh0838

# 1. 数据类型

JNI 程序中涉及了三种数据类型,分别是:

  • Java 类型
  • JNI 类型
  • C/C++ 类型

在 Java 程序中我们使用的是 Java 类型,C/C++ 程序中拿到的是 JNI 类型,我们需要将其转换为 C/C++ 类型,使用 C/C++ 类型再去调用 C/C++ 层函数完成计算或 IO 操作等任务后,将结果再转换为 JNI 类型返回后,在 java 代码中,我们就能收到对应的 Java 类型。

我们可以在 $JAVA_HOME/inlcude/jni.h 文件中查看到 jni 中基本类型的定义:

typedef unsigned char   jboolean;
typedef unsigned short  jchar;
typedef short           jshort;
typedef float           jfloat;
typedef double          jdouble;
typedef jint            jsize;
1
2
3
4
5
6

$JAVA_HOME/include/linux/jni_md.h 中定义了 jbyte, jint and jlong 和 CPU 平台相关的类型:

typedef int jint;
#ifdef _LP64
typedef long jlong;
#else
typedef long long jlong;
#endif

typedef signed char jbyte;
1
2
3
4
5
6
7
8

以上这些类型我们称之为基本数据类型,其关系梳理如下:

Java 类型 JNI 类型 C/C++ 类型
boolean jboolean unsigned char
byte jbyte signed char
char jchar unsigned short
short jshort signed short
int jint int
long jlong long
float jfloat float
double jdouble double

这些类型不需要进行转换,可以直接在 JNI 中使用:

jbyte result=0xff;
jint size;
jbyte* timeBytes;
1
2
3

jni.h 中定义的非基本数据类型称为引用类型

#ifdef __cplusplus

class _jobject {};
class _jclass : public _jobject {};
class _jthrowable : public _jobject {};
class _jstring : public _jobject {};
class _jarray : public _jobject {};
class _jbooleanArray : public _jarray {};
class _jbyteArray : public _jarray {};
class _jcharArray : public _jarray {};
class _jshortArray : public _jarray {};
class _jintArray : public _jarray {};
class _jlongArray : public _jarray {};
class _jfloatArray : public _jarray {};
class _jdoubleArray : public _jarray {};
class _jobjectArray : public _jarray {};

typedef _jobject *jobject;
typedef _jclass *jclass;
typedef _jthrowable *jthrowable;
typedef _jstring *jstring;
typedef _jarray *jarray;
typedef _jbooleanArray *jbooleanArray;
typedef _jbyteArray *jbyteArray;
typedef _jcharArray *jcharArray;
typedef _jshortArray *jshortArray;
typedef _jintArray *jintArray;
typedef _jlongArray *jlongArray;
typedef _jfloatArray *jfloatArray;
typedef _jdoubleArray *jdoubleArray;
typedef _jobjectArray *jobjectArray;

#else

struct _jobject;

typedef struct _jobject *jobject;
typedef jobject jclass;
typedef jobject jthrowable;
typedef jobject jstring;
typedef jobject jarray;
typedef jarray jbooleanArray;
typedef jarray jbyteArray;
typedef jarray jcharArray;
typedef jarray jshortArray;
typedef jarray jintArray;
typedef jarray jlongArray;
typedef jarray jfloatArray;
typedef jarray jdoubleArray;
typedef jarray jobjectArray;

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

总结如下:

java 类型 JNI 引用类型 类型描述
java.lang.Object jobject 表示任何Java的对象
java.lang.String jstring Java的String字符串类型的对象
java.lang.Class jclass Java的Class类型对象
java.lang.Throwable jthrowable Java的Throwable类型
byte[] jbyteArray Java byte型数组
Object[] jobjectArray Java任何对象的数组
boolean[] jbooleanArray Java boolean型数组
char[] jcharArray Java char型数组
short[] jshortArray Java short型数组
int[] jintArray Java int型数组
long[] jlongArray Java long型数组
float[] jfloatArray Java float型数组
double[] jdoubleArray Java double型数组

# 2. 数据类型转换

native 程序主要做了这么几件事:

  1. 接收 JNI 类型的参数
  2. 参数类型转换,JNI 类型转换为 Native 类型
  3. 执行 Native 代码
  4. 创建一个 JNI 类型的返回对象,将结果拷贝到这个对象并返回结果

其中很多代码都是在做类型转换的操作,下面我们来看看类型转换的示例。

# 2.1 基本类型

基本类型无需做转换,直接使用:

java 层:

private native double average(int n1, int n2);
1

c/c++ 层:

JNIEXPORT jdouble JNICALL Java_HelloJNI_average(JNIEnv *env, jobject jobj, jint n1, jint n2) {
    //基本类型不用做转换,直接使用
    cout << "n1 = " << n1 << ", n2 = " << n2 << endl;
    return jdouble(n1 + n2)/2.0;
}
1
2
3
4
5

# 2.2 字符串

为了在 C/C++ 中使用 Java 字符串,需要先将 Java 字符串转换成 C 字符串。用 GetStringChars 函数可以将 Unicode 格式的 Java 字符串转换成 C 字符串,用 GetStringUTFChars 函数可以将 UTF-8 格式的 Java 字符串转换成 C 字符串。这些函数的第三个参数均为 isCopy,它让调用者确定返回的 C 字符串地址指向副本还是指向堆中的固定对象。

java 层:

private native String sayHello(String msg);
1

c/c++ 层:

jJNIEXPORT jstring JNICALL Java_HelloJNI_sayHello__Ljava_lang_String_2(JNIEnv *env, jobject jobj, jstring str) {
  
    //jstring -> char*
    jboolean isCopy;
    //GetStringChars 用于 unicode 编码
    //GetStringUTFChars 用于 utf-8 编码
    const char* cStr = env->GetStringUTFChars(str, &isCopy);
  
    if (nullptr == cStr) {
        return nullptr;
    }

    if (JNI_TRUE == isCopy) {
        cout << "C 字符串是 java 字符串的一份拷贝" << endl;
    } else {
        cout << "C 字符串指向 java 层的字符串" << endl;
    }

    cout << "C/C++ 层接收到的字符串是 " << cStr << endl;
  
    //通过JNI GetStringChars 函数和 GetStringUTFChars 函数获得的C字符串在原生代码中
    //使用完之后需要正确地释放,否则将会引起内存泄露。
    env->ReleaseStringUTFChars(str, cStr);

    string outString = "Hello, JNI";
    // char* 转换为 jstring
    return env->NewStringUTF(outString.c_str());
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

# 2.3 数组

数组的操作与字符串类似:

java 层:

private native double[] sumAndAverage(int[] numbers);
1

c++ 层:

JNIEXPORT jdoubleArray JNICALL Java_HelloJNI_sumAndAverage(JNIEnv *env, jobject obj, jintArray inJNIArray) {
    //类型转换 jintArray -> jint*
    jboolean isCopy;
    jint* inArray = env->GetIntArrayElements(inJNIArray, &isCopy);

    if (JNI_TRUE == isCopy) {
        cout << "C 层的数组是 java 层数组的一份拷贝" << endl;
    } else {
        cout << "C 层的数组指向 java 层的数组" << endl;
    }

    if(nullptr == inArray) return nullptr;
    //获取到数组长度
    jsize length = env->GetArrayLength(inJNIArray);

    jint sum = 0;
    for(int i = 0; i < length; ++i) {
        sum += inArray[i];
    }

    jdouble average = (jdouble)sum / length;
    //释放数组
    env->ReleaseIntArrayElements(inJNIArray, inArray, 0); // release resource

    //构造返回数据,outArray 是指针类型,需要 free 或者 delete 吗?要的
    jdouble outArray[] = {sum, average};
    jdoubleArray outJNIArray = env->NewDoubleArray(2);
    if(NULL == outJNIArray) return NULL;
    //向 jdoubleArray 写入数据
    env->SetDoubleArrayRegion(outJNIArray, 0, 2, outArray);
    return outJNIArray;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

其他类型的装换都大体类似,大家可以举一反三。

# 3. 引用类型

我们先回顾一下 Native 层和 Java 层里对象的创建和销毁的过程

  • 以 C++ 为例,Native 层中要创建一个对象的话需使用 new 操作符先分配内存,然后构造对象。如果不再使用这个对象,则需要通过操作符先析构这个对象,然后回收该对象所占的内存。
  • Java 层中也通过 new 操作来构造一个对象。如果后续不再使用它,则可以显式地设置持有这个对象的变量的值为 null(也可以不做这一步,而交由垃圾回收来扫描和标记该对象是否有被引用)。该对象所占的内存则在垃圾回收过程中被收回。

JNI 层作为 Java 层和 Native 层之间相交互的中间层,它兼具 Native 层和 Java 层的某些特性,尤其在对引用对象的创建和回收上。

  • 和 C++ 里的 new 操作符可以创建一个对象类似,JNI 层可以利用 JNI NewObject 等函数创建一个 Java 意义的对象(引用型对象)。这个被 New 出来的对象是局部(Local) 型的引用对象。
  • JNI 层可通过 DeleteLocalRef 释放 Local 型的引用对象(等同于Java 层中设置持有这个对象的变量的值为 null)。如果不调用 DeleteLocalRef 的话,根据 JNI 规范,Local 型对象在 JNI 函数返回后,也会由虚拟机根据垃圾回收的逻辑进行标记和回收。
  • 除了 Local 型对象外,JNI 层借助JNI Global 相关函数可以将一个 Local 型引用对象转换成一个全局(Global) 型对象。而 Global 型对象的回收只能先由程序显式地调用 Global 相关函数进行删除,然后,虚拟机才能借助垃圾回收机制回收它们

引用类型针对的是除开基本类型的 JNI 类型,比如 jstring, jclass ,jobject 等。JNI 类型是 java 层与 c 层的中间类型,java 层与 c 层都需要管理他。我们可以将 JNI 引用类型理解为 Java 意义的对象。

JNI 类型根据使用的方式可分为:

  • 局部引用
  • 全部引用
  • 弱全部引用

# 3.1 局部引用

# 3.1.1 局部引用与概念

什么是局部引用?

通过 JNI 接口从 Java 传递下来或者通过 NewLocalRef 和各种 JNI 接口(FindClass、NewObject、GetObjectClass和NewCharArray等)创建的引用称为局部引用。

局部引用的特点?

  • 在函数为执行完毕前,局部引用会阻止 GC 回收所引用的对象
  • 局部引用不能在本地函数中跨函数使用,不能跨线程使用,当然也不能直接缓存起来使用
  • 函数返回后(未返回局部引用的情况下),局部引用所引用的对象会被 JVM 自动释放,也可在函数结束前通过 DeleteLocalRef 函数手动释放
  • 如果 c 函数返回了一个局部引用数据,在 java 层,该类型会转换为对应的 java 类型。当 java 层不存在该对象的引用时,gc 就会回收该对象

一个常见的错误是使用静态变量保存局部引用,试图缓存变量提高性能:

JNIEXPORT jstring JNICALL Java_HelloJNI_sayHello(JNIEnv *env, jobject obj)
{
    static jclass cls_string = NULL;
    if (cls_string == NULL) {
        cls_string = (*env)->FindClass(env, "java/lang/String");
        if (cls_string == NULL) {
            return NULL;
        }
    }
 
    return (*env)->NewStringUTF(env,"Hello from JNI !");
}
1
2
3
4
5
6
7
8
9
10
11
12

cls_string 是一个局部引用,当 native 函数执行完成后,gc 可能会回收掉 cls_string 指向的内存。下次调用该函数时,cls_string 存储的就是一个被释放后的内存地址,成了一个野指针。严重的,造成非法地址的访问,程序崩溃。

# 3.1.2 释放局部变量

释放一个局部引用有两种方式:

  • 本地方法执行完毕后 JVM 自动释放,
  • 自己调用 DeleteLocalRef 手动释放

既然 JVM 会在函数返回后会自动释放所有局部引用,为什么还需要手动释放呢? 以下几种情况下,为了避免内存溢出,我们应该手动释放局部引用:

  1. JNI 会将创建的局部引用都存储在一个局部引用表中,如果这个表超过了最大容量限制,就会造成局部引用表溢出,使程序崩溃。经测试,Android上的 JNI 局部引用表最大数量是 512 个。当我们在实现一个本地方法时,可能需要创建大量的局部引用,如果没有及时释放,就有可能导致 JNI 局部引用表的溢出,所以,在不需要局部引用时就立即调用 DeleteLocalRef 手动删除。

    for (i = 0; i < len; i++) {
         jstring jstr = (*env)->GetObjectArrayElement(env, arr, i);
         ... /* 使用jstr */
         (*env)->DeleteLocalRef(env, jstr); // 使用完成之后马上释放
    }
    
    1
    2
    3
    4
    5
  2. 在编写 JNI 工具函数时,工具函数在程序当中是公用的,被谁调用你是不知道的。其内部的局部引用在使用完成后应该立即释放,避免过多的内存占用。

  3. 如果你的本地函数不会返回。比如一个接收消息的函数,里面有一个死循环,用于等待别人发送消息过来 while(true) { if (有新的消息) { 处理之。。。。} else { 等待新的消息。。。}}。如果在消息循环当中创建的引用你不显示删除,很快将会造成JVM局部引用表溢出。

  4. 局部引用使用完了就删除,而不是要等到函数结尾才释放,局部引用会阻止所引用的对象被 GC 回收。比如你写的一个本地函数中刚开始需要访问一个大对象,因此一开始就创建了一个对这个对象的引用,但在函数返回前会有一个大量的非常复杂的计算过程,而在这个计算过程当中是不需要前面创建的那个大对象的引用的。但是,在计算的过程当中,如果这个大对象的引用还没有被释放的话,会阻止 GC 回收这个对象,内存一直占用者,造成资源的浪费。所以这种情况下,在进行复杂计算之前就应该把引用给释放了,以免不必要的资源浪费。

言而总之,当一个局部引用不在使用后,立即将其释放,以避免不必要的内存浪费。

# 3.1.3 局部引用的管理

JNI 的规范指出,JVM 要确保每个 Native 方法至少可以创建 16 个局部引用,经验表明,16 个局部引用已经足够平常的使用了。

但是,如果要与 JVM 中的对象进行复杂的交互计算,就需要创建更多的局部引用了,这时就需要使用 EnsureLocalCapacity 来确保可以创建指定数量的局部引用,如果创建成功返回 0 ,返回返回小于 0 ,如下代码示例:

    // Use EnsureLocalCapacity
    int len = 20;
    if (env->EnsureLocalCapacity(len) < 0) {
        // 创建失败,out of memory
    }
    for (int i = 0; i < len; ++i) {
        jstring  jstr = env->GetObjectArrayElement(arr,i);
        // 处理 字符串
        // 创建了足够多的局部引用,这里就不用删除了,显然占用更多的内存
    }
1
2
3
4
5
6
7
8
9
10

确保可以创建了足够的局部引用数量,所以在循环处理局部引用时可以不进行删除了,但是显然会消耗更多的内存空间了。

PushLocalFrame 与 PopLocalFrame 是两个配套使用的函数对。它们可以为局部引用创建一个指定数量内嵌的空间,在这个函数对之间的局部引用都会在这个空间内,直到释放后,所有的局部引用都会被释放掉,不用再担心每一个局部引用的释放问题了。

常见的使用场景就是在循环中:

 // Use PushLocalFrame & PopLocalFrame
    for (int i = 0; i < len; ++i) {
        if (env->PushLocalFrame(len)) { // 创建指定数据的局部引用空间
            //out ot memory
        }
        jstring jstr = env->GetObjectArrayElement(arr, i);
        // 处理字符串
        // 期间创建的局部引用,都会在 PushLocalFrame 创建的局部引用空间中
        // 调用 PopLocalFrame 直接释放这个空间内的所有局部引用
        env->PopLocalFrame(NULL); 
    }
1
2
3
4
5
6
7
8
9
10
11

使用 PushLocalFrame & PopLocalFrame 函数对,就可以在期间放心地处理局部引用,最后统一释放掉。

# 3.2 全局引用

全局引用可以跨方法、跨线程使用,直到它被手动释放才会失效。同局部引用一样,也会阻止它所引用的对象被 GC 回收。与局部引用不一样的是,函数执行完后,GC 也不会回收全局引用指向的对象。与局部引用创建方式不同的是,只能通过 NewGlobalRef 函数创建。

    static jclass cls_string = NULL;
    if (cls_string == NULL) {
        jclass local_cls_string = (*env)->FindClass(env, "java/lang/String");
        if (cls_string == NULL) {
            return NULL;
        }

        // 将java.lang.String类的Class引用缓存到全局引用当中
        cls_string = (*env)->NewGlobalRef(env, local_cls_string);

        // 删除局部引用
        (*env)->DeleteLocalRef(env, local_cls_string);

        // 再次验证全局引用是否创建成功
        if (cls_string == NULL) {
            return NULL;
        }
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

当我们的本地代码不再需要一个全局引用时,应该马上调用 DeleteGlobalRef 来释放它。如果不手动调用这个函数,即使这个对象已经没用了,JVM 也不会回收这个全局引用所指向的对象。

# 3.3 弱全局引用

弱全局引用使用 NewGlobalWeakRef 创建,使用 DeleteGlobalWeakRef 释放。下面简称弱引用。与全局引用类似,弱引用可以跨方法、线程使用。但与全局引用很重要不同的一点是,弱引用不会阻止 GC 回收它引用的对象。

    static jclass myCls2 = NULL;
    if (myCls2 == NULL)
    {
        jclass myCls2Local = (*env)->FindClass(env, "mypkg/MyCls2");
        if (myCls2Local == NULL)
        {
            return; /* 没有找到mypkg/MyCls2这个类 */
        }
        myCls2 = NewWeakGlobalRef(env, myCls2Local);
        if (myCls2 == NULL)
        {
            return; /* 内存溢出 */
        }
    }
    ... /* 使用myCls2的引用 */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 3.4 引用比较

IsSameObject 用来判断两个引用是否指向相同的对象。还可以用 isSameObject 来比较弱全局引用所引用的对象是否被 GC 了,返回 JNI_TRUE 则表示回收了,JNI_FALSE 则表示未被回收。

env->IsSameObject(obj1, obj2) // 比较两个引用是否指向相同的对象
env->IsSameObject(obj, NULL)  // 比较局部引用或者全局引用是否为 NULL
env->IsSameObject(wobj, NULL) // 比较弱全局引用所引用对象是否被 GC 回收
1
2
3

一些疑问

如果 C 层返回给 java 层一个全局引用,这个全局引用何时可以被 GC 回收?

我认为不会被 GC 回收,造成内存泄漏。

所以 JNI 函数如果要返回一个对象,我们应该使用局部引用作为返回值。